Skip to content

feat(oidc): JWT userinfo support for eSignet [DHIS2-20043]#23839

Draft
netroms wants to merge 27 commits into
masterfrom
feat/DHIS2-20043-esignet-oidc-jwt-userinfo
Draft

feat(oidc): JWT userinfo support for eSignet [DHIS2-20043]#23839
netroms wants to merge 27 commits into
masterfrom
feat/DHIS2-20043-esignet-oidc-jwt-userinfo

Conversation

@netroms

@netroms netroms commented May 7, 2026

Copy link
Copy Markdown
Contributor

Summary

  • Adds per-provider opt-in for OIDC providers that return userinfo as a signed JWT (application/jwt) instead of JSON. Selected via two new generic-provider config keys: user_info_response_type=json|jwt (default json) and user_info_jws_algorithm (default RS256, allow-list of RSA/ECDSA only).
  • Default behaviour is unchanged for every existing provider (Google, Azure AD, WSO2, generic OIDC, internal DHIS2-as-IdP). DhisOidcUserService regains extends OidcUserService and dispatches to either Spring's standard JSON path or a new SignedJwtUserInfoLoader based on the registration's userInfoResponseType.
  • Replaces the all-or-nothing approach in PR feat: add support for eSignet OIDC --- DONT DELETE!!! #22027 (which broke JSON userinfo for everything else). Generic parser now relaxes client_secret only when client_authentication_method=private_key_jwt is satisfied; user_info_uri and jwk_uri remain required.

Notable

  • SignedJwtUserInfoLoader verifies the userinfo JWT against the IdP's JWKS via a per-registration JwkSourceCache (Nimbus's JWKSource is built once and reused so its remote-key cache works as intended).
  • Distinct OAuth2Error codes for the JWT path: invalid_user_info_response, jwt_processing_error, missing_mapping_claim, user_not_found, user_disabled.
  • Principal-name attribute stays sub in both modes so audit logs are unaffected.
  • Algorithm allow-list fails closed at config-parse time, not at authentication time.
  • The eSignet integration is configured purely through generic provider keys — no bespoke EsignetProvider class. A complete dhis.conf example lives in the design spec.

Test plan

  • dhis-services/dhis-service-core full module test: 789 pass, 0 fail (1 new vs master).
  • mvn spotless:check clean.
  • New unit coverage: SupportedJwsAlgorithmsTest (13 cases incl. parameterized algorithms + HMAC rejection), GenericOidcProviderBuilderConfigParserTest (+6 cases for JWT mode and private_key_jwt relaxation), GenericOidcProviderBuilderTest (+4 cases for builder field wiring + missing-secret behaviour), SignedJwtUserInfoLoaderTest (6 cases: happy path, HTTP failure, bad signature, missing mapping claim, unknown user, disabled user), DhisOidcUserServiceDispatchTest (2 cases: JSON and JWT branch dispatch), SignedJwtUserInfoLoaderHttpIntegrationTest (4 cases: real HTTP via MockServer).
  • Manual end-to-end test against an eSignet sandbox before merge (reviewer / @netroms).
  • Smoke test against an existing JSON-userinfo provider (e.g. Google) to confirm zero behavioural change.

AI Assisted

netroms and others added 14 commits May 6, 2026 23:53
…0043]

Per-provider config switch (user_info_response_type=json|jwt, default
json) lets eSignet's signed-JWT userinfo flow be selected via dhis.conf
without altering behaviour for any existing OIDC provider. Generic
provider builder is the integration point. Captures scope, component
contracts, validation rules, tests, and the decision to keep
principalNameAttribute=sub in both modes.

AI Assisted

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…[DHIS2-20043]

Eleven-task TDD plan covering enum, algorithm allow-list, registration
fields, generic builder wiring, parser validation, JWKSource cache,
SignedJwtUserInfoLoader, DhisOidcUserService dispatch, OAuth2Test
re-enable, and PublicKeysController thumbprint cherry-pick.

AI Assisted

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…HIS2-20043]

AI Assisted

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…onfig keys [DHIS2-20043]

AI Assisted

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ation [DHIS2-20043]

AI Assisted

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…nder private_key_jwt [DHIS2-20043]

- Register USER_INFO_RESPONSE_TYPE and USER_INFO_JWS_ALGORITHM in the
  KEY_REQUIRED_MAP allow-list so the parser accepts them as valid keys
- Add validateUserInfoResponseType() to reject unknown response type
  values and unsupported JWS algorithms (via SupportedJwsAlgorithms)
- Add isPrivateKeyJwt() helper and skip the client_secret required-check
  when private_key_jwt + all keystore properties are present
- Wire userInfoResponseType and userInfoJwsAlgorithm in
  GenericOidcProviderBuilder.build() so the registration reflects config
- Guard clientSecret null check in builder (was NPE for private_key_jwt)

AI Assisted

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
… [DHIS2-20043]

- Replace lenient null-check with logic that throws unless private_key_jwt
- Add isPrivateKeyJwt() helper for reusable auth-method detection
- Guard getJWK() against null keystore path when no keystore configured
- Skip clientSecret on ClientRegistration when null/empty
- Add four direct GenericOidcProviderBuilder unit tests covering
  JWT/JSON userinfo defaults, missing-secret enforcement, and
  private_key_jwt bypass

AI Assisted

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
AI Assisted

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…2-20043]

Mockito's bundled ByteBuddy (1.14.x) only formally supports Java up to
20; running on Java 21 needs the experimental flag to inline-mock final
classes. Adding it to surefireArgLine + threading argLine through the
surefire pluginManagement keeps Java 17 builds unaffected and unblocks
local Java 21 dev-machine builds.

AI Assisted

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…[DHIS2-20043]

Loads OIDC userinfo from providers (e.g. MOSIP eSignet) that respond
with a signed JWT instead of plain JSON: GET userinfo with
Accept: application/jwt → verify signature against the IdP's JWKS using
the registered JWS algorithm → extract the mapping claim → resolve the
local DHIS2 user. principalNameAttribute remains sub so audit logs match
the JSON path. Reuses the existing restTemplate bean from
dhis-support-system. Distinct OAuth2Error codes for fetch failure,
JWS-verification failure, missing mapping claim, and unknown user.

AI Assisted

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…-20043]

AI Assisted

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Additive: emits x509 SHA-256 thumbprint on the JWK only when the
underlying registration's JWK carries one (returns null otherwise, which
Nimbus's RSAKey builder accepts). Cherry-picked from PR #22027.

AI Assisted

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…KS [DHIS2-20043]

The previous SignedJwtUserInfoLoader was passing
DhisOidcClientRegistration.jwkSetUrl (which is DHIS2's *own* JWKS URL,
used to advertise client-auth keys for private_key_jwt) to JwkSourceCache
when verifying the userinfo JWT. That URL would never resolve the IdP's
signing keys, so signature verification would always fail in production.

Switched to ClientRegistration.providerDetails.jwkSetUri (populated from
the jwk_uri config key — already required and validated). Updated the
test to mock that endpoint accordingly. No extra parser validation is
needed because jwk_uri is already in the required-keys map.

Also addressed code-review nits in the same change:
- replaced Collections.singletonList with List.of (per AGENTS.md)
- added disabledUserRaisesUserDisabled test exercising the user_disabled
  error code path

AI Assisted

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@netroms
netroms marked this pull request as draft May 7, 2026 09:56
netroms and others added 4 commits May 7, 2026 18:57
…r [DHIS2-20043]

Stands up the JDK's built-in HttpServer as an in-process eSignet-style
IdP and exercises the full stack (real RestTemplate, real JwkSourceCache,
real Nimbus JWT verification) end to end. Zero new dependencies.

Covers happy path plus three negatives: bad-key JWT -> jwt_processing_error,
IdP 500 -> invalid_user_info_response, and missing mapping claim ->
missing_mapping_claim.

AI Assisted

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two follow-up docs for the signed-JWT userinfo work:

* OAUTH2_JWT_USERINFO_HARDENING.md — four defense-in-depth items
  surfaced by the security review (sub binding per OIDC 5.3.2, iss/aud
  verification, private_key_jwt parser strictness, error-description
  hygiene). Not exploitable today; track for hardening before eSignet
  prod use.

* OIDC_E2E_MOCK_IDP_PLAN.md — comprehensive plan for a generic OIDC
  RP-side e2e test harness: small custom Java mock IdP service in the
  e2e module (signed-JWT userinfo is rare enough that no off-the-shelf
  option supports it), per-realm scenario API, test class per OIDC
  feature, compose overlay and CI wiring. Phase 0 is the in-process
  HTTP integration test landed in this branch; phases 1-5 are scoped
  for follow-up work.

AI Assisted

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not sure what's the point of this docs directory. Presumably it's meant for a bot agent? Let's leave this outside of this PR since we shouldn't introduce a new WoW without a discussion with the wider team.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks, this was not meant to be in the PR.

@cjmamo

cjmamo commented May 19, 2026

Copy link
Copy Markdown
Contributor

Is this going to be backported to v43 @netroms ? We need the feature for this particular version.

HttpEntity<String> entity = new HttpEntity<>("", headers);
try {
ResponseEntity<String> response =
restTemplate.exchange(userInfoUri, HttpMethod.GET, entity, String.class);

@cjmamo cjmamo May 19, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Are there any timeouts set on this instance of RestTemplate?

clientRegistration.getRegistrationId());
/**
* JSON-userinfo path: delegates to Spring's {@link OidcUserService#loadUser(OidcUserRequest)},
* then resolves the mapping claim to a local DHIS2 user. Package-private to allow direct stubbing

@cjmamo cjmamo May 19, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Redundant comment IMO:

Package-private to allow direct stubbing in {@link DhisOidcUserServiceDispatchTest}.

If the access modifier is narrowed by mistake, then the test would break at compilation so there's really no need to say this.

*/
@ExtendWith(MockitoExtension.class)
@MockitoSettings(strictness = Strictness.LENIENT)
class SignedJwtUserInfoLoaderHttpIntegrationTest {

@cjmamo cjmamo May 20, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I got an earful last time when I wrote an integration test in the dhis-service-core module. This should be moved to the dhis-test-integration or dhis-test-web-api modules.

Comment on lines +121 to +126
httpServer = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
httpServer.createContext("/jwks.json", ex -> respond(ex, 200, "application/json", publicJwks));
httpServer.createContext("/userinfo", this::serveUserInfo);
httpServer.setExecutor(null);
httpServer.start();
port = httpServer.getAddress().getPort();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I believe this together with the stub plumbing can be reduced if MockServer is used instead. We already use MockServer in other places within the test code base. Besides, the HttpServer API is relatively low-level.

netroms and others added 3 commits May 25, 2026 03:41
Add RestTemplate timeouts (5s connect, 10s read), remove redundant
Javadoc comment, move HTTP integration test to dhis-test-web-api with
MockServer, and drop docs/superpowers/ from the PR.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…20043]

Add missing assertion in parseAcceptsMissingClientSecretWithPrivateKeyJwt
and extract Map.of() from assertThrows lambda in
buildThrowsWhenSecretMissingAndNotPrivateKeyJwt.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
netroms and others added 6 commits May 25, 2026 04:35
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…n [DHIS2-20043]

Guard against null JWK in PublicKeysController.getKeys() which would
NPE for non-private_key_jwt providers. Narrow catch(Exception) to
catch(BadJOSEException | JOSEException | ParseException) so
programming errors are not masked. Add null guard for
userInfoJwsAlgorithm at the top of SignedJwtUserInfoLoader.load().

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
… exception handling [DHIS2-20043]

Make resolveAlgorithm null-safe to prevent NPE when getJwk() is null
for non-private_key_jwt providers. Narrow catch clause in
SignedJwtUserInfoLoader.verify() from Exception to specific JOSE types.
Add fail-fast check for null userInfoJwsAlgorithm in JWT mode.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…s [DHIS2-20043]

Extract resolveExternalAuthUser as a shared package-private static
method on SignedJwtUserInfoLoader. Both JSON and JWT paths now use the
same user lookup, externalAuth check, and disabled/expired validation
with consistent error codes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
… integration test [DHIS2-20043]

Replace @Autowired field injection with constructor injection using
private final fields in DhisOidcUserService. Move HTTP integration
test from dhis-test-web-api to dhis-test-integration where it belongs.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…tegration [DHIS2-20043]

Add mockserver-client-java, testcontainers, spring-security-oauth2-client,
and nimbus-jose-jwt as explicit test dependencies to satisfy the
dependency analysis plugin.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@sonarqubecloud

Copy link
Copy Markdown

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants